Skip to content

fix: stop duplicated assistant text and stuck tool spinners - #224

Merged
yuga-hashimoto merged 1 commit into
mainfrom
fix/chat-duplicate-text-and-stuck-tool-status
Aug 8, 2026
Merged

fix: stop duplicated assistant text and stuck tool spinners#224
yuga-hashimoto merged 1 commit into
mainfrom
fix/chat-duplicate-text-and-stuck-tool-status

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

  • While the agent is streaming, assistant text can appear twice: text/thinking content blocks never carry an id in Claude Code's stream-json protocol, so the final full-message replay for a block got a random UUID instead of the deterministic id already used by the streamed deltas, producing a duplicate part.
  • Tool call cards could get stuck showing "実行中" (running) forever: tool_result lines report their own (often absent) message id rather than the id of the message that held the running tool_use, so the result landed on a stranded new message and the original card was never updated.

Fix

  • ClaudeStreamJsonParser.parseContentBlock: give text/thinking blocks the same deterministic part id ("$messageId-text" / "$messageId-reasoning") that parsePartialDelta already uses for the streamed deltas.
  • ClaudeStreamJsonParser.parseModelMessage: track the originating message id per open tool call (openTools) and route a tool_result's parts back onto that message, merging into its existing parts instead of replacing the message wholesale (important since this state is also what's persisted to disk via ClaudeMessageStore.upsert).

Test plan

  • Added two regression tests to ClaudeStreamJsonParserTest: one asserting a replayed full text block merges onto the streamed part instead of duplicating it, one asserting a tool_result with an unrelated message id lands back on the originating tool_use message.
  • Full ClaudeStreamJsonParserTest suite (12 tests) passes, verified via a standalone kotlinc + JUnit run (the sandboxed build environment here can't run the full Gradle Android build — aapt2 is x86-64-only in this ARM/proot host — so this was run outside Gradle).
  • CI

🤖 Generated with Claude Code

… Code chat

Claude Code's stream-json protocol assigns mismatched ids across events for
the same logical block: text content blocks never carry an "id" so the
final full-message replay got a random UUID instead of the deterministic id
streamed deltas already used, producing a duplicate text part. Similarly,
tool_result lines report their own (often absent) message id rather than
the id of the message that held the running tool_use, so results landed on
a stranded new message and the original tool card never left the "running"
state.

Give text/thinking blocks the same deterministic part id as their delta
stream, and route tool_result parts back onto the tool_use's originating
message (merging into it instead of replacing it, since that store is also
what gets persisted to disk).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ Successfully posted inline: 3 comment(s)

Comment on lines +92 to +95
val originMessageId =
blocks.firstNotNullOfOrNull { block ->
if (block.string("type") == "tool_result") openTools[block.string("tool_use_id")]?.messageId else null
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
originMessageId の決定に blocks.firstNotNullOfOrNull を使い、最初にマッチした tool_result の属先メッセージに newParts 全体を紐付けています。1つの user メッセージに複数の assistant メッセージ由来の tool_result が含まれる場合、または先頭の tool_result の tool_use_id が既に openTools から削除済み(先に settle 済みなど)で次の tool_result にフォールバックする場合、後続の tool_result が誤ったメッセージにマージされ、本来更新されるべき tool_use カードが未更新のまま残る可能性があります。ブロックごとに属先メッセージを解決し、メッセージ単位でまとめてマージする方が安全です。

if (block.string("type") == "tool_result") openTools[block.string("tool_use_id")]?.messageId else null
}
val messageId = originMessageId ?: message.string("id") ?: newMessageId()
currentMessageId = messageId

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · low]
tool_result を元の assistant メッセージにルーティングした結果、currentMessageId が originMessageId(過去の実在メッセージ)に設定されます。parsePartialDelta は currentMessageId を messageId として使うため、直後の assistant ターンのテキストデルタ(content_block_delta)が元メッセージのパーツID "$originMessageId-text" に紐付けられ、新しい応答のテキストが前のメッセージに誤って反映される恐れがあります。ルーティング後は currentMessageId を更新しない(または次ターン用にリセットする)など、tool_result 受信時に currentMessageId を汚さない設計にしてください。

OpenCodePart(partId, sessionId, messageId, "text", text = block.string("text").orEmpty())
// Must match parsePartialDelta's id for the same field so the final full-message
// replay overwrites the streamed-in text instead of appearing as a duplicate block.
OpenCodePart("$messageId-text", sessionId, messageId, "text", text = block.string("text").orEmpty())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[bug · medium]
text ブロックのパーツIDが "$messageId-text" に固定されており、thinking も "$messageId-reasoning" 固定です。1つのメッセージに text ブロックが複数含まれる場合(例: text→tool_use→text、Claude Code の content 配列は複数ブロックを許可)、すべて同じパーツIDになります。この結果、既存メッセージとのマージ時(byId[it.id] = it の後勝ち上書き)に先頭の text ブロックが失われるほか、events にも同一IDのパーツが複数流れ、UI側のパーツ更新が競合します。parsePartialDelta の "$messageId-$field" と一致させる必要があるのは理解できますが、複数ブロックを安全に扱うなら、ブロックごとの一意情報(content_block の index など)をIDに含めることを検討してください。

@yuga-hashimoto
yuga-hashimoto merged commit c7c8838 into main Aug 8, 2026
5 checks passed
@yuga-hashimoto
yuga-hashimoto deleted the fix/chat-duplicate-text-and-stuck-tool-status branch August 8, 2026 11:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant